Что нового в Java 25. Часть 1

Что нового в Java 25. Часть 1

Выход Java 25 LTS назначен на 16 сентября. Это первый LTS-релиз с выхода Java 21 в сентябре 2023 года. Java 25 LTS получит минимум пять лет поддержки от Axiom.

Релиз-кандидат уже доступен, а список фичей заморожен. В JDK 25 нас ожидает 18 JEP, охватывающих язык, библиотеки, многопоточность, профилирование, производительность и сборку мусора.

Скачать Axiom JDK 25 вы сможете в личном кабинете после его официального релиза.

Из-за количества JEP в релизе мы решили разделить статью на несколько частей. В первой части мы рассмотрим 6 финализированных JEP, которые вошли в новую Java.

Содержание

Категории JEP

Как использовать фичи в предварительной и экспериментальной версиях

Чтобы попробовать экспериментальные фичи или фичи в предварительной версии, нужно их явно включить. Вы можете это сделать как через командную строку, так и настроить в интегрированной среде разработки (IDE). Некоторые фичи можно включать на уровне javac, а не только при запуске java.

В командной строке включите фичу в предварительной версии одним из следующих способов:

  • Скомпилируйте программу с помощью javac --release 24 --enable-preview Main.java и запустите ее с помощью java --enable-preview Main.
  • При использовании source code launcher запустите программу с java --enable-preview Main.java.
  • При использовании jshell запустите его с помощью jshell --enable-preview.

JEP 521: Generational Shenandoah GC

Режим поколений для сборщика мусора Shenandoah GC был представлен в JDK 24 (JEP 404) в качестве экспериментальной фичи, а утвердится в JDK 25. Режим работы Shenandoah GC по умолчанию (режим одного поколения, single generation) не изменится.

Shenandoah GC — это сборщик мусора с низкой задержкой: он почти всю работу делает параллельно с вашим приложением, включая сжатие данных в памяти. Поэтому паузы при его работе остаются короткими, даже если куча (heap) огромная.

Изначально Shenandoah был non-generational, то есть не делил объекты на молодые и старые, как G1 GC, Parallel GC и ZGC (с JDK 23). Из-за этого приходилось держать больше свободной памяти и тратить больше времени на обработку «долго живущих» объектов.

Теперь появился generational Shenandoah, который сможет поддерживать молодое и старое поколения, позволяя чаще собирать молодые объекты. Shenandoah будет собирать либо только молодые объекты, либо одновременно и молодые, и старые, как G1 GC. Shenandoah GC выполняет смешанные сборки (mixed collections) с учётом уникальной модели работы с регионами и барьерами. Это снижает нагрузку и делает работу ещё стабильнее. При этом главный плюс Shenandoah остаётся — паузы не зависят от размера кучи, потому что сборка идёт вместе с работой приложения.

Кроме того, новый режим сам подстраивает размеры поколений и связанные параметры работы, чтобы экономить память и держать задержки как можно меньше.

Чтобы включить режим поколений для Shenandoah GC, укажите следующие параметры JVM:

$ java -XX:+UseShenandoahGC \
       -XX:ShenandoahGCMode=generational ...

JEP 519: Компактные заголовки объектов (Compact Object Headers)

В JDK 24 (JEP 450) были представлены компактные заголовки объектов, которые утвердятся в JDK 25.
Цель компактных заголовков объекта — уменьшить размер заголовков Java-объектов в HotSpot JVM с 96 или 128 бит до 64 бит на 64-битных архитектурах (x64 и AArch64). Это призвано уменьшить использование CPU и/или памяти Java-приложениями.

Ряд экспериментов показал, что включение компактных заголовков объектов улучшает производительность:

  • В одном сценарии бенчмарк SPECjbb2015 использовал на 22% меньше памяти кучи и на 8% меньше процессорного времени.
  • В другом сценарии количество сборок мусора, выполненных SPECjbb2015, снизилось на 15% как для сборщика G1, так и для Parallel GC.
  • Бенчмарк высокопараллельного JSON-парсера выполнялся на 10% быстрее.

Напоминаем, что результат зависит от ваших объектов. Если у вас много маленьких объектов, то эффект будет ощутимым, а если объекты большие (данные больше заголовка), то ощутимого эффекта вы не получите.

Чтобы включить компактные заголовки объектов, укажите опцию -XX:+UseCompactObjectHeaders в командной строке:

$ java -XX:+UseCompactObjectHeaders ...

JEP 506: Scoped Values

Scoped Values появились в инкубаторе в Java 20, остались на второе, третье и четвёртое превью, а теперь утвердятся в Java 25 с одним небольшим изменением: метод ScopedValue.orElse больше не принимает null в качестве аргумента.

При разработке Java-приложений нередко возникает задача передать данные между методами. Например, веб-фреймворк принимает входящие HTTP-запросы и вызывает обработчик приложения, чтобы их обработать. Обработчик приложения снова обращается к фреймворку, чтобы получить данные из базы данных или вызвать другой HTTP-сервис.

Фреймворк может использовать объект FrameworkContext, который содержит ID аутентифицированного пользователя, ID транзакции и др., и связывать его с текущей транзакцией. Все операции фреймворка работают с объектом FrameworkContext, но для пользовательского кода этот объект не имеет значения.

Обычно в таком случае используют переменные типа ThreadLocal:

public class Framework {

    private final Application application;

    public Framework(Application app) { this.application = app; }
    
    private static final ThreadLocal<FrameworkContext> CONTEXT 
                       = new ThreadLocal<>();    // объявляем переменную типа ThreadLocal

    void serve(Request request, Response response) {
        var context = createContext(request);
        CONTEXT.set(context);                    // записываем значение FrameworkContext в переменную
        Application.handle(request, response);
    }

    public PersistedObject readKey(String key) {
        var context = CONTEXT.get();              // считываем значение переменной, чтобы получить FrameworkContext
        var db = getDBConnection(context);
        db.readKey(key);
    }

}

Переменная типа Threadlocal служит скрытым параметром метода. Поток, в котором вызывается CONTEXT.set в Framework.serve, а затем CONTEXT.get в Framework.readKey, автоматически увидит локальную копию переменной CONTEXT. По сути, поле ThreadLocal — ключ для поиска значения FrameworkContext для текущего потока.

Однако у ThreadLocal есть несколько недостатков:

  • Неконтролируемая изменяемость. Любую переменную типа ThreadLocal можно изменить в любое время через метод set(), даже если она хранит неизменяемый объект.
  • Неограниченный срок жизни. Значение переменной ThreadLocal сохраняется до завершения исполнения потока или до явного вызова метода remove(), про который часто забывают. Это может привести к утечкам памяти, особенно при пуле потоков. Если переменные активно изменяются, будет сложно определить, когда безопасно вызвать remove().
  • Дорогое наследование. Когда дочерние потоки наследуют переменные ThreadLocal родительского потока, для каждого дочернего потока требуется отдельное хранилище для всех переменных ThreadLocal родителя. Это увеличивает использование памяти и ресурсов, даже если эти переменные редко изменяются в дочерних потоках.

Использование виртуальных потоков усложнит ситуацию, поскольку число виртуальных потоков может быть сильно больше, чем обычных потоков.

Проблемы использования ThreadLocal решаются ScopedValue. Значение в ScopedValue в отличие от ThreadLocal записывается один раз и доступно только в течение ограниченного периода времени во время исполнения потока.

1) Создаётся объект ScopedValue, не связанный ни с одним потоком.
2) В коде вызывается метод where(), который принимает значение (scoped value) и объект ScopedValue.
3) Затем метод run() привязывает значение к объекту, создавая копию scoped value для текущего потока, и запускает переданное лямбда-выражение. Пока выполняется метод run(), лямбда-выражение и любые методы, вызванные из него напрямую или косвенно, могут получать доступ к scoped value через метод get(). После завершения метода run() scoped value отвязывается от объекта.

С помощью ScopedValue пример с фреймворком можно переписать следующим образом:

class Framework {

    private static final ScopedValue<FrameworkContext> CONTEXT
                        = ScopedValue.newInstance();    // объявляем переменную типа ScopedValue

    void serve(Request request, Response response) {
        var context = createContext(request);
        ScopedValue.where(CONTEXT, context)                         // вызываем метод where...run вместо метода ThreadLocal.set()
                   .run(() -> Application.handle(request, response));
    }
    
    public PersistedObject readKey(String key) {
        var context = CONTEXT.get();                    // считываем значение переменной
        var db = getDBConnection(context);
        db.readKey(key);
    }

}

В общем случае, рекомендуется переходить c ThreadLocal на ScopedValue, когда цель использования — однонаправленная передача неизменяемых данных. Однако есть несколько сценариев, в которых лучше использовать ThreadLocal. Например, кэширование объектов, которые дорого создавать и использовать.

Scoped value API доступно по ссылке.

JEP 511: Объявление импорта модулей (Module Import Declarations)

Объявление импорта модулей было представлено в JDK 23 (JEP 476) в предварительной версии, затем улучшено в JDK 24 (JEP 494) и утвердится в JDK 25.

Эта фича позволяет вместо явного импортирования отдельных пакетов, классов и интерфейсов импортировать модуль со всеми его публичными классами и интерфейсами, а также модулями, от которых он зависит транзитивно. Это упростит переиспользование модульных библиотек путём одновременного импортирования модулей, снизит необходимость в множестве отдельных import-директив и упростит работу с кодом, особенно на ранних этапах разработки.

Импорт модуля выглядит следующим образом:

import module M;

Так импортируются все публичные классы и интерфейсы верхнего уровня из пакетов, экспортируемых модулем M, и его транзитивных зависимостей в текущий модуль.

Например, для работы с коллекциями и потоками требовалось вручную импортировать каждую зависимость:

import java.util.Map;
import java.util.function.Function;
import java.util.stream.Collectors;
import java.util.stream.Stream;

String[] fruits = { "apple", "berry", "citrus" };
Map<String, String> m = Stream.of(fruits)
    .collect(Collectors.toMap(s -> s.toUpperCase().substring(0, 1), Function.identity()));

С выходом Java 9 появилась система модулей, которая объединяет набор пакетов в единое целое. Логично было бы иметь возможность импортировать сразу весь модуль.

Например, import module java.base; даст доступ к List, Map, Stream и Path без десятка отдельных import java.util.*, import java.util.stream.* и т. д.

Особенно это полезно в ситуациях, когда API одного модуля тесно связано с API другого. Например, java.sql зависит от java.xml. С новым синтаксисом достаточно написать import module java.sql;, чтобы импортировать всё нужное.

Теперь можно просто импортировать весь модуль java.base, содержащий List, Map, Stream, Path и другие классы:

import module java.base;

String[] fruits = { "apple", "berry", "citrus" };
Map<String, String> m = Stream.of(fruits)
    .collect(Collectors.toMap(s -> s.toUpperCase().substring(0, 1), Function.identity()));

Если один модуль экспортирует классы с одинаковыми именами, компилятор выдаст ошибку. Например:

import module java.base;    // java.util.List
import module java.desktop; // java.awt.List

List l = ...; // ошибка: неоднозначность

Решение — использовать явный импорт класса нужного модуля:

import java.util.List; // теперь всё однозначно

Ещё вариант — использовать более широкий import package.*, который затмит модульный импорт.

Приоритеты следующие:

  1. Однотипный импорт (наиболее специфичный).
  2. Пакетный импорт *.
  3. Импорт модуля (наименее специфичный).

Также раньше приходилось писать:

import javax.xml.*;
import javax.xml.parsers.*;
import javax.xml.stream.*;

Теперь достаточно:

import module java.xml;

При использовании компактных исходных файлов (JEP 512) модуль java.base импортируется автоматически. Это значит, что List, Map, Stream и прочие сразу доступны без необходимости отдельного импорта их соответствующих пакетов.

В JShell список автоподключаемых пакетов будет заменён на import module java.base;.

java.se не экспортирует пакеты сам по себе, но транзитивно тянет 19 модулей.

import module java.se;

Теперь это приведёт к импорту всех стандартных API Java, включая java.base.

Разработчики этого JEP считают, что импорт на уровне модулей делает код чище и удобнее, особенно при использовании больших API. Однако возможны конфликты имён, которые решаются с помощью явного импорта.

Вообще говоря, традиции не рекомендуют использовать даже конструкции вида import bla.*.

В таком ключе import module начинает играть новыми красками. Хорошо, если используется продвинутая IDE, которая всё сама подскажет и подсветит. Если же вы смотрите код в блокноте, терминале или в веб-интерфейсе для код-ревью, то начинается игра в угадайку.

Также импорт модулей не из поставки JDK — это лотерея, в которой мы не знаем, что же этот модуль транзитивно с собой принесёт.

JEP 503: Удаление 32-битного порта x86-систем

В JDK 25 будет удалён исходный код и прекращена поддержка сборки для 32-битного порта систем x86. В JDK 24 (JEP 501) этот порт был помечен как подлежащий удалению (deprecated).

Причина удаления — стоимость поддержки этого порта превышает его пользу.
Предполагается, что индустрия окончательно перешла в 64-битный мир. Новое 32-битное x86-железо больше не производится, а оставшиеся развёртывания 32-битного x86 — это наследие. Windows 10, последняя версия Windows с поддержкой 32-битных систем, перестанет поддерживаться в октябре 2025 года, а порт Windows 32-битного x86 уже удалён из JDK (JEP 479). Debian также планирует прекратить поддержку 32-битного x86 в ближайшем будущем.

32-битный порт x86 всё ещё можно собрать, хотя он не поддерживается и не гарантирует адекватной производительности.

Удаление порта позволит разработчикам ускорить внедрение новых функций и улучшений.

JEP 510: Key Derivation Function API

В JDK 24 (JEP 478) в пакете javax.crypto появилось новое API, реализующее функции генерации производного ключа (Key Derivation Function, KDF) — javax.crypto.KDF. В JDK 25 это API утвердится без изменений.

Функции выработки ключей (KDF) используют криптографические входные данные (например, исходный материал для ключа, значение соли и псевдослучайную функцию) для создания нового криптографически стойкого ключевого материала. С KDF можно получать несколько ключей из одного источника, что обеспечит безопасность и возможность воспроизведения при наличии одинаковых входных данных у двух сторон.

API KEM (JEP 452), интегрированный в JDK 21, вместе с KDF API являются важными шагами для поддержки гибридного шифрования с открытым ключом (Hybrid Public Key Encryption, HPKE) в Java. Этот алгоритм шифрования обеспечивает плавный переход к алгоритмам, устойчивым к квантовым атакам.

Стандарт PKCS#11 для криптографических аппаратных устройств уже много лет описывает поддержку KDF. Доступность KDF через javax.crypto даст удобство для приложений и библиотек, работающих с такими устройствами. Разработчики сторонних криптопровайдеров также смогут предлагать свои реализации KDF.

С KDF API Java-платформа даст улучшенную поддержку для современных функций выработки ключей на основе паролей, более совершенных, чем PBKDF1 и PBKDF2, например Argon2. Ни один из существующих криптографических API в Java не способен “из коробки” представлять KDF.

Класс KDF определяет два метода для выработки ключей:

Используется пустой интерфейс AlgorithmParameterSpec, так как различные алгоритмы KDF требуют разных параметров. Реализация KDF должна определить один или несколько подклассов AlgorithmParameterSpec для описания своих параметров.

Для включённой реализации HKDF предоставляются три таких подкласса, каждый из которых соответствует определённому режиму работы:

Интерфейс HKDFParameterSpec содержит статические фабричные методы для создания экземпляров этих трёх классов, а также класс KDFParameterSpec.Builder для сборки ключевого материала для операций извлечения.

// Создание объекта KDF для указанного алгоритма
KDF hkdf = KDF.getInstance("HKDF-SHA256");

// Создание спецификации параметров Extract-Expand
AlgorithmParameterSpec params =
    HKDFParameterSpec.ofExtract()
                     .addIKM(initialKeyMaterial)
                     .addSalt(salt).thenExpand(info, 32);

// Выработка 32-байтового ключа AES
SecretKey key = hkdf.deriveKey("AES", params);

// Можно вызывать deriveKey повторно с тем же объектом KDF

В будущем планируется добавить поддержку Argon2.

KDF API лучше подходит для выведения ключей, чем старое API на основе классов KeyGenerator и SecretKeyFactory. KeyGenerator рассчитан на добавление энтропии через SecureRandom для генерации недетерминированного ключа из входных данных, а KDF предполагает возможность независимой детерминированной выработки ключей двумя сторонами. Также SecretKeyFactory рассчитан на создание одного ключа, а KDF должен поддерживать последовательные детерминированные выработки из потока ключевого материала.

Заключение

Мы рассмотрели первую часть JEP в Java 25. Во второй части мы рассмотрим больше JEP и посмотрим куда развивается Java.

Author image

Сергей Лунегов

Директор по продуктам Axiom JDK

Axiom JDK info@axiomjdk.ru Axiom JDK logo Axiom Committed to Freedom 199 Obvodnogo Kanala Emb. 190020 St. Petersburg RU +7 812-336-35-67 Axiom JDK 199 Obvodnogo Kanala Emb. 190020 St. Petersburg RU +7 812-336-35-67